--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 86acc89f9010f489fc930c5dce988e2b1865855a
Parents : fd36a8f
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-24T07:47:20-05:00
feat: various bug fixes
Changes
15 files changed, 470 insertions(+), 35 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75412fce..8334c7ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -78,6 +78,11 @@ All notable changes to this project will be documented in this file.
- Map my-location prefers LXMF address hash telemetry (not only identity hash)
- RNode flasher SRI lookup matches flat and nested integrity.json keys so zip.min.js loads
- RRC kick/ban and failed auto-rejoin clear unread_counts so room pills do not stick
+- Nomad archive load owns its download_id so path-mismatched replies cannot leave the spinner stuck forever
+- RRC invite-only (+i) rooms grant a reconnect invite on unexpected link drop so auto-rejoin still works after the one-shot invite was consumed
+- Map remote overlay loads undo layers from a stale generation, and TileCache map view keys are identity-scoped
+- Nomad micron LXMF links route to Messages via onLxmfAddress instead of being ignored
+- RNSH session config_path and identity_path are jailed under storage or the shared Reticulum config dir, and free-form extra_args are rejected
- Desktop AppImage: main-process logs always append to the storage logs folder (meshchatx.log). Stdout is only used when a terminal is attached, with broken-pipe guards as a fallback, so background launches no longer raise write EPIPE dialogs
- Android: lxmfy packaging, flock soft-lock, splash/logo clipping, Landlock skipped on Android
- Android RNode BLE/USB via Chaquopy
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 523441f1..bc897128 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/src/backend/http/routes/rn_tools.py b/meshchatx/src/backend/http/routes/rn_tools.py
index 91937214..d1cca213 100644
--- a/meshchatx/src/backend/http/routes/rn_tools.py
+++ b/meshchatx/src/backend/http/routes/rn_tools.py
@@ -156,7 +156,10 @@ def register_rn_tools_routes(routes, app):
if error is not None:
return error
data = await request.json()
- session = manager.create_session(data or {})
+ try:
+ session = manager.create_session(data or {})
+ except ValueError as e:
+ return web.json_response({"message": str(e)}, status=400)
autostart = bool((data or {}).get("autostart", True))
if autostart:
try:
diff --git a/meshchatx/src/backend/http/ws/handlers_nomad.py b/meshchatx/src/backend/http/ws/handlers_nomad.py
index cfc96fb5..42308111 100644
--- a/meshchatx/src/backend/http/ws/handlers_nomad.py
+++ b/meshchatx/src/backend/http/ws/handlers_nomad.py
@@ -204,7 +204,24 @@ async def handle_nomadnet_page_archives_get(app, client, data):
async def handle_nomadnet_page_archive_load(app, client, data):
archive_id = data.get("archive_id")
+ download_id = data.get("download_id")
if archive_id is None:
+ AsyncUtils.run_async(
+ client.send_str(
+ json.dumps(
+ {
+ "type": "nomadnet.page.download",
+ "download_id": download_id,
+ "nomadnet_page_download": {
+ "status": "failure",
+ "destination_hash": "",
+ "page_path": "",
+ "failure_reason": "missing archive_id",
+ },
+ },
+ ),
+ ),
+ )
return
archive = app.database.misc.get_archived_page_by_id(archive_id)
@@ -215,7 +232,7 @@ async def handle_nomadnet_page_archive_load(app, client, data):
json.dumps(
{
"type": "nomadnet.page.download",
- "download_id": data.get("download_id"),
+ "download_id": download_id,
"nomadnet_page_download": {
"status": "success",
"destination_hash": archive["destination_hash"],
@@ -228,6 +245,24 @@ async def handle_nomadnet_page_archive_load(app, client, data):
),
),
)
+ return
+
+ AsyncUtils.run_async(
+ client.send_str(
+ json.dumps(
+ {
+ "type": "nomadnet.page.download",
+ "download_id": download_id,
+ "nomadnet_page_download": {
+ "status": "failure",
+ "destination_hash": "",
+ "page_path": "",
+ "failure_reason": "archive not found",
+ },
+ },
+ ),
+ ),
+ )
# handle flushing all archived pages
diff --git a/meshchatx/src/backend/rnsh_manager.py b/meshchatx/src/backend/rnsh_manager.py
index 1bfdc645..b7cdb764 100644
--- a/meshchatx/src/backend/rnsh_manager.py
+++ b/meshchatx/src/backend/rnsh_manager.py
@@ -23,6 +23,8 @@ import time
import uuid
from collections import deque
+from meshchatx.src.path_utils import is_path_within_dir
+
try:
import fcntl
import pty
@@ -157,7 +159,10 @@ class RNSHSession:
"""
configured = (self.config.get("config_path") or "").strip()
if configured:
- return configured
+ resolved = self.manager.resolve_allowed_path(configured)
+ if resolved is None:
+ raise ValueError("config_path is outside the allowed directories")
+ return resolved
manager_config_dir = getattr(self.manager, "reticulum_config_dir", "")
if isinstance(manager_config_dir, str):
return manager_config_dir.strip()
@@ -301,7 +306,10 @@ class RNSHSession:
identity_path = (self.config.get("identity_path") or "").strip()
if identity_path:
- command.extend(["-i", identity_path])
+ resolved_identity = self.manager.resolve_allowed_path(identity_path)
+ if resolved_identity is None:
+ raise ValueError("identity_path is outside the allowed directories")
+ command.extend(["-i", resolved_identity])
verbose = int(self.config.get("verbose") or 0)
quiet = int(self.config.get("quiet") or 0)
@@ -323,7 +331,7 @@ class RNSHSession:
extra_args = (self.config.get("extra_args") or "").strip()
if extra_args:
- command.extend(shlex.split(extra_args))
+ raise ValueError("extra_args is not allowed")
mode = self.mode
if mode == "listen":
@@ -699,6 +707,54 @@ class RNSHManager:
self._output_callback = None
self._lock = threading.RLock()
+ def resolve_allowed_path(self, user_path):
+ """Resolve a user path under storage or the shared Reticulum config dir.
+
+ Returns the realpath on success, or None when the path escapes the jail.
+ """
+ if not isinstance(user_path, str) or not user_path.strip() or "\x00" in user_path:
+ return None
+ expanded = os.path.expanduser(user_path.strip())
+ if not os.path.isabs(expanded):
+ if not self.storage_dir:
+ return None
+ expanded = os.path.join(self.storage_dir, expanded)
+ real = os.path.realpath(expanded)
+ parts = {part for part in real.split(os.sep) if part}
+ if parts & {".ssh", ".gnupg"}:
+ return None
+ allowed_roots = []
+ if self.storage_dir:
+ allowed_roots.append(self.storage_dir)
+ if self.reticulum_config_dir:
+ allowed_roots.append(self.reticulum_config_dir)
+ for root in allowed_roots:
+ if is_path_within_dir(real, root):
+ return real
+ return None
+
+ def sanitize_session_config(self, config):
+ """Fail closed on path overrides and reject free-form extra_args."""
+ if not isinstance(config, dict):
+ return {}
+ out = dict(config)
+ for key in ("config_path", "identity_path"):
+ raw = out.get(key)
+ if raw in (None, ""):
+ out.pop(key, None)
+ continue
+ if not isinstance(raw, str):
+ raise ValueError(f"Invalid {key}")
+ resolved = self.resolve_allowed_path(raw)
+ if resolved is None:
+ raise ValueError(f"{key} is outside the allowed directories")
+ out[key] = resolved
+ extra = out.get("extra_args")
+ if isinstance(extra, str) and extra.strip():
+ raise ValueError("extra_args is not allowed")
+ out.pop("extra_args", None)
+ return out
+
def set_change_callback(self, callback):
self._change_callback = callback
@@ -725,7 +781,11 @@ class RNSHManager:
session_id = item.get("id")
if not isinstance(session_id, str) or not session_id.strip():
continue
- session = RNSHSession(self, session_id, item.get("config") or item)
+ try:
+ cfg = self.sanitize_session_config(item.get("config") or item)
+ except ValueError:
+ continue
+ session = RNSHSession(self, session_id, cfg)
session.created_at = float(item.get("created_at") or session.created_at)
session.updated_at = float(item.get("updated_at") or session.updated_at)
session.status = RNSHSession.STATUS_STOPPED
@@ -794,7 +854,11 @@ class RNSHManager:
def create_session(self, config):
session_id = uuid.uuid4().hex
- session = RNSHSession(self, session_id, config or {})
+ session = RNSHSession(
+ self,
+ session_id,
+ self.sanitize_session_config(config or {}),
+ )
with self._lock:
self._sessions[session_id] = session
self._on_session_change(session)
diff --git a/meshchatx/src/backend/rrc/server.py b/meshchatx/src/backend/rrc/server.py
index 75d92bd3..2060ba26 100644
--- a/meshchatx/src/backend/rrc/server.py
+++ b/meshchatx/src/backend/rrc/server.py
@@ -26,7 +26,7 @@ from meshchatx.src.backend.rrc.room_key_crypto import (
room_keys_equal,
)
from meshchatx.src.backend.rrc.room_registry import RoomRegistry
-from meshchatx.src.backend.rrc.rooms_toml import RoomsTomlStore
+from meshchatx.src.backend.rrc.rooms_toml import RoomsTomlStore, INVITE_DEFAULT_TTL_S
SERVER_DIR_NAME = "rrc_server"
MESSAGE_LOG_CAP = 5000
@@ -311,21 +311,34 @@ class RRCHubServer:
def _on_close(self, link):
parted = []
+ reconnect_invites = []
with self._lock:
sess = self._sessions.pop(link, None)
if sess is None:
return
+ peer = sess.peer
for room in list(sess.rooms):
members = self._room_members.get(room)
- if members is None:
- continue
- members.discard(link)
- remaining = list(members)
- reg = self.rooms.get_state(room)
- if not members and (reg is None or not reg.get("registered")):
- self._room_members.pop(room, None)
- if remaining and sess.peer is not None:
- parted.append((room, sess.peer, sess.nick, remaining))
+ remaining = []
+ if members is not None:
+ members.discard(link)
+ remaining = list(members)
+ reg = self.rooms.get_state(room)
+ if not members and (reg is None or not reg.get("registered")):
+ self._room_members.pop(room, None)
+ else:
+ reg = self.rooms.get_state(room)
+ if remaining and peer is not None:
+ parted.append((room, peer, sess.nick, remaining))
+ # Unexpected drop (not PART/kick): grant a one-shot reconnect
+ # invite so auto-rejoin works after +i invite was consumed.
+ if (
+ peer is not None
+ and isinstance(reg, dict)
+ and reg.get("invite_only")
+ and not self.rooms.is_room_banned(room, peer)
+ ):
+ reconnect_invites.append((room, peer))
for room, peer, nick, remaining in parted:
env = proto.make_envelope(
proto.T_PARTED,
@@ -337,6 +350,10 @@ class RRCHubServer:
payload = proto.encode(env)
for member in remaining:
self._send_payload(member, payload)
+ for room, peer in reconnect_invites:
+ with contextlib.suppress(Exception):
+ self.rooms.add_invite(room, peer, ttl_s=INVITE_DEFAULT_TTL_S)
+ self.rooms.persist(room)
self.manager._notify_change(self)
def _refill_and_take(self, sess, cost=1.0):
diff --git a/meshchatx/src/frontend/components/map/MapBrowser.vue b/meshchatx/src/frontend/components/map/MapBrowser.vue
index 8780afd6..bb0b4484 100644
--- a/meshchatx/src/frontend/components/map/MapBrowser.vue
+++ b/meshchatx/src/frontend/components/map/MapBrowser.vue
@@ -81,9 +81,14 @@ import MapPage from "./MapPage.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import TileCache from "../../js/TileCache";
import GlobalEmitter from "../../js/GlobalEmitter";
+import GlobalState from "../../js/GlobalState";
import { loadMapTabs, saveMapTabs } from "../../js/browserLayoutStore";
+import {
+ LEGACY_MAP_STATE_KEY,
+ legacyMapTabStateKey,
+ mapViewStateKey,
+} from "../../js/mapStateKeys.js";
-const LEGACY_MAP_STATE_KEY = "last_view";
const DOUBLE_TAP_MS = 400;
function createStorageId() {
@@ -335,15 +340,22 @@ export default {
},
async migrateLegacyMapState(storageId) {
try {
+ const identityHash = GlobalState.config?.identity_hash || null;
+ const tabKey = mapViewStateKey(identityHash, storageId);
+ const existing = await TileCache.getMapState(tabKey);
+ if (existing) {
+ return;
+ }
+ const unscopedTab = await TileCache.getMapState(legacyMapTabStateKey(storageId));
+ if (unscopedTab) {
+ await TileCache.setMapState(tabKey, unscopedTab);
+ return;
+ }
const legacy = await TileCache.getMapState(LEGACY_MAP_STATE_KEY);
if (!legacy) {
return;
}
- const tabKey = `map_tab_${storageId}`;
- const existing = await TileCache.getMapState(tabKey);
- if (!existing) {
- await TileCache.setMapState(tabKey, legacy);
- }
+ await TileCache.setMapState(tabKey, legacy);
} catch {
// migration is best-effort
}
diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index bdbf6c92..575c5305 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -1019,6 +1019,8 @@ import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
import DOMPurify from "dompurify";
import ToastUtils from "../../js/ToastUtils";
import TileCache from "../../js/TileCache";
+import { mapViewStateKey } from "../../js/mapStateKeys.js";
+import GlobalState from "../../js/GlobalState";
import {
detectRasterTileProviderId,
nextRasterTileProviderId,
@@ -1268,10 +1270,8 @@ export default {
},
computed: {
mapStateKey() {
- if (this.tabStorageId) {
- return `map_tab_${this.tabStorageId}`;
- }
- return "last_view";
+ const identityHash = this.config?.identity_hash || GlobalState.config?.identity_hash || null;
+ return mapViewStateKey(identityHash, this.tabStorageId || null);
},
popoutRouteType() {
if (this.$route?.meta?.popoutType) {
@@ -4394,6 +4394,7 @@ export default {
try {
await this.ensureRemoteOverlayLayer(overlay);
if (gen !== this.remoteOverlayLoadGeneration) {
+ this.removeRemoteOverlayLayer(id);
return;
}
const entry = this.remoteOverlayLayers[id];
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index 4f5fcf33..a6744492 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -1318,6 +1318,13 @@ export default {
onNomadUrl: (url) => {
this.onNodePageUrlClick(url, null, true, false, this.getLinkNavOptions(event));
},
+ onLxmfAddress: (address) => {
+ const routeName = this.isPopoutMode ? "messages-popout" : "messages";
+ this.$router.push({
+ name: routeName,
+ params: { destinationHash: address },
+ });
+ },
onOpenNode: (destination, fields) => {
this.onNodePageUrlClick(destination, fields, true, false, this.getLinkNavOptions(event));
},
@@ -1363,6 +1370,28 @@ export default {
return;
}
+ if (
+ nomadnetPageDownload.status === "failure" &&
+ this.isLoadingNodePage &&
+ this.currentPageDownloadId === downloadId &&
+ !this.nomadnetPageDownloadCallbacks[
+ this.getNomadnetPageDownloadCallbackKey(
+ nomadnetPageDownload.destination_hash,
+ nomadnetPageDownload.page_path
+ )
+ ]
+ ) {
+ this.isLoadingNodePage = false;
+ this.nodePageLoadPhase = null;
+ this.currentPageDownloadId = null;
+ this.nodePageProgress = 0;
+ ToastUtils.error(this.$t("nomadnet.failed_to_load_page"));
+ this.nodePageContent = `Failed loading page: ${
+ nomadnetPageDownload.failure_reason || "archive not found"
+ }`;
+ return;
+ }
+
// ignore response if it's for a different page than currently requested/viewed
// but allow responses for partial pages (they have registered callbacks)
if (this.nodePagePath && responsePagePath !== this.nodePagePath) {
@@ -2423,9 +2452,14 @@ export default {
return;
}
- // lxmf urls should open the conversation
- const normalizedLxmf = Utils.normalizeMeshchatHashHex(url);
- if (normalizedLxmf.length === 32 && (url.startsWith("lxmf@") || url.startsWith("lxmf://"))) {
+ // lxmf urls should open the conversation (not a Nomad node tab)
+ const urlTrimmed = typeof url === "string" ? url.trim() : "";
+ const normalizedLxmf = Utils.normalizeMeshchatHashHex(urlTrimmed);
+ const lxmfLower = urlTrimmed.toLowerCase();
+ if (
+ normalizedLxmf.length === 32 &&
+ (lxmfLower.startsWith("lxmf@") || lxmfLower.startsWith("lxmf://"))
+ ) {
const destinationHash = normalizedLxmf;
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
await this.$router.push({
@@ -2697,13 +2731,25 @@ export default {
this.nodePagePathUrlInput = this.nodePagePath;
}
- WebSocketConnection.send(
+ // Own the reply even when the local archive list is empty or stale.
+ // Without this, ownsNomadPageDownloadEvent rejects path-mismatched
+ // archive payloads and isLoadingNodePage stays true forever.
+ const downloadId = Math.floor(Math.random() * 1000000);
+ this.currentPageDownloadId = downloadId;
+
+ const sent = WebSocketConnection.send(
JSON.stringify({
type: "nomadnet.page.archive.load",
archive_id: archiveId,
- download_id: Math.floor(Math.random() * 1000000),
+ download_id: downloadId,
})
);
+ if (sent === false) {
+ this.isLoadingNodePage = false;
+ this.nodePageLoadPhase = null;
+ this.currentPageDownloadId = null;
+ ToastUtils.error(this.$t("nomadnet.tab_restore_failed"));
+ }
},
manualArchive() {
if (!this.selectedNode || !this.nodePagePath || !this.nodePageContent) return;
diff --git a/meshchatx/src/frontend/js/mapStateKeys.js b/meshchatx/src/frontend/js/mapStateKeys.js
new file mode 100644
index 00000000..cafff05c
--- /dev/null
+++ b/meshchatx/src/frontend/js/mapStateKeys.js
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Identity-scoped TileCache map_state keys so view state does not leak across identities.
+ */
+
+export const LEGACY_MAP_STATE_KEY = "last_view";
+
+export function mapViewStateKey(identityHash, tabStorageId = null) {
+ const id = String(identityHash || "anon")
+ .toLowerCase()
+ .replace(/[^0-9a-f]/g, "")
+ .slice(0, 16);
+ const scoped = id.length > 0 ? id : "anon";
+ if (tabStorageId) {
+ return `map_tab_${scoped}_${tabStorageId}`;
+ }
+ return `last_view_${scoped}`;
+}
+
+export function legacyMapTabStateKey(tabStorageId) {
+ return `map_tab_${tabStorageId}`;
+}
diff --git a/tests/backend/test_rnsh_api.py b/tests/backend/test_rnsh_api.py
index b7ab64b9..04940bf2 100644
--- a/tests/backend/test_rnsh_api.py
+++ b/tests/backend/test_rnsh_api.py
@@ -3,6 +3,7 @@
"""Tests for RNSh manager HTTP API endpoints and launcher argv."""
import json
+import os
from unittest.mock import MagicMock
import pytest
@@ -269,6 +270,12 @@ def test_rnsh_frozen_listen_command_keeps_mirror_flag_separate(monkeypatch):
manager = MagicMock()
manager.reticulum_config_dir = "/tmp/rns-config"
+ manager.storage_dir = "/tmp/rns-config"
+ manager.resolve_allowed_path = lambda p: (
+ os.path.realpath(p)
+ if os.path.realpath(p).startswith(os.path.realpath("/tmp/rns-config"))
+ else None
+ )
session = rnsh_mod.RNSHSession(
manager,
"s1",
@@ -277,7 +284,7 @@ def test_rnsh_frozen_listen_command_keeps_mirror_flag_separate(monkeypatch):
"mirror": True,
"no_auth": True,
"quiet": 1,
- "config_path": "/tmp/session-config",
+ "config_path": "/tmp/rns-config/session-config",
},
)
command = session._build_command()
@@ -289,7 +296,9 @@ def test_rnsh_frozen_listen_command_keeps_mirror_flag_separate(monkeypatch):
assert command.count("-m") == 1
assert command.index("-m") > command.index(rnsh_mod._RNSH_MODULE)
assert "--rnsconfig" in command
- assert command[command.index("--rnsconfig") + 1] == "/tmp/session-config"
+ assert command[command.index("--rnsconfig") + 1] == os.path.realpath(
+ "/tmp/rns-config/session-config"
+ )
assert "-l" in command
assert "-n" in command
assert "-q" in command
@@ -584,3 +593,52 @@ def test_rnsh_manager_save_is_atomic(tmp_path):
assert not (tmp_path / "rnsh_sessions.json.tmp").exists()
data = json.loads(store.read_text(encoding="utf-8"))
assert len(data["sessions"]) == 1
+
+
+def test_oracle_rnsh_rejects_config_path_outside_jail(tmp_path):
+ from meshchatx.src.backend.rnsh_manager import RNSHManager
+
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ bait = tmp_path / "outside"
+ bait.mkdir()
+ (bait / "secret").write_text("nope", encoding="utf-8")
+
+ manager = RNSHManager(str(storage), reticulum_config_dir=str(storage / "rns"))
+ with pytest.raises(ValueError, match="config_path"):
+ manager.create_session(
+ {
+ "mode": "listen",
+ "config_path": str(bait),
+ },
+ )
+ assert (bait / "secret").read_text(encoding="utf-8") == "nope"
+
+
+def test_oracle_rnsh_rejects_extra_args(tmp_path):
+ from meshchatx.src.backend.rnsh_manager import RNSHManager
+
+ manager = RNSHManager(str(tmp_path))
+ with pytest.raises(ValueError, match="extra_args"):
+ manager.create_session(
+ {
+ "mode": "listen",
+ "extra_args": "--help; cat /etc/passwd",
+ },
+ )
+
+
+def test_oracle_rnsh_allows_relative_config_under_storage(tmp_path):
+ from meshchatx.src.backend.rnsh_manager import RNSHManager
+
+ storage = tmp_path / "storage"
+ cfg = storage / "alt-rns"
+ cfg.mkdir(parents=True)
+ manager = RNSHManager(str(storage), reticulum_config_dir=str(storage / "rns"))
+ session = manager.create_session(
+ {
+ "mode": "listen",
+ "config_path": "alt-rns",
+ },
+ )
+ assert session.config["config_path"] == os.path.realpath(str(cfg))
diff --git a/tests/backend/test_rnsh_live.py b/tests/backend/test_rnsh_live.py
index 9c5633bd..eb331b1e 100644
--- a/tests/backend/test_rnsh_live.py
+++ b/tests/backend/test_rnsh_live.py
@@ -41,6 +41,11 @@ class _LiveManager:
self.changes = 0
self.outputs = 0
+ def resolve_allowed_path(self, user_path):
+ from meshchatx.src.backend.rnsh_manager import RNSHManager
+
+ return RNSHManager.resolve_allowed_path(self, user_path)
+
def _on_session_change(self, _session):
self.changes += 1
diff --git a/tests/backend/test_rrc_oracle_bugs.py b/tests/backend/test_rrc_oracle_bugs.py
index 6c4974f8..69909a22 100644
--- a/tests/backend/test_rrc_oracle_bugs.py
+++ b/tests/backend/test_rrc_oracle_bugs.py
@@ -382,6 +382,48 @@ def test_oracle_join_strips_key_whitespace():
assert "vault" in sess.rooms
+def test_oracle_invite_only_link_drop_allows_rejoin():
+ """After +i invite is consumed, link drop must still allow auto-rejoin.
+
+ Intentional PART still requires a fresh invite (see
+ test_oracle_invite_consumed_after_join).
+ """
+ server = make_server()
+ link_op, sess_op = add_session(server, b"\xaa" * 16, nick="op")
+ link_guest, sess_guest = add_session(server, b"\xbb" * 16, nick="guest")
+ server.register_room("club", invite_only=True, founder=sess_op.peer)
+ join(server, link_op, sess_op, "club")
+ server.rooms.add_invite("club", sess_guest.peer, ttl_s=60)
+ first = join(server, link_guest, sess_guest, "club")
+ assert envs_of_type(first, proto.T_JOINED, to_link=link_guest)
+ assert not server.rooms.is_invited("club", sess_guest.peer)
+
+ server._on_close(link_guest)
+ assert server.rooms.is_invited("club", sess_guest.peer)
+
+ link_guest2, sess_guest2 = add_session(server, b"\xbb" * 16, nick="guest")
+ second = join(server, link_guest2, sess_guest2, "club")
+ assert envs_of_type(second, proto.T_JOINED, to_link=link_guest2)
+ assert not server.rooms.is_invited("club", sess_guest.peer)
+
+
+def test_oracle_invite_only_part_still_requires_fresh_invite():
+ server = make_server()
+ link_op, sess_op = add_session(server, b"\xaa" * 16, nick="op")
+ link_guest, sess_guest = add_session(server, b"\xbb" * 16, nick="guest")
+ server.register_room("club", invite_only=True, founder=sess_op.peer)
+ join(server, link_op, sess_op, "club")
+ server.rooms.add_invite("club", sess_guest.peer, ttl_s=60)
+ join(server, link_guest, sess_guest, "club")
+ part(server, link_guest, sess_guest, "club")
+ assert not server.rooms.is_invited("club", sess_guest.peer)
+ denied = join(server, link_guest, sess_guest, "club")
+ assert any(
+ e.get(proto.K_BODY) == "invite-only (+i)"
+ for e in envs_of_type(denied, proto.T_ERROR)
+ )
+
+
def test_oracle_invite_consumed_after_join():
server = make_server()
link_op, sess_op = add_session(server, b"\xaa" * 16, nick="op")
diff --git a/tests/frontend/NomadNetworkPage.test.js b/tests/frontend/NomadNetworkPage.test.js
index 4cf68e3b..c7139297 100644
--- a/tests/frontend/NomadNetworkPage.test.js
+++ b/tests/frontend/NomadNetworkPage.test.js
@@ -904,4 +904,91 @@ describe("NomadNetworkPage.vue", () => {
wrapper.unmount();
});
});
+
+ describe("archive load ownership", () => {
+ it("loadArchivedPage sets currentPageDownloadId so path-mismatched replies still apply", async () => {
+ const WebSocketConnection = (await import("@/js/WebSocketConnection")).default;
+ WebSocketConnection.send.mockReturnValue(true);
+
+ const wrapper = mountNomadNetworkPage({
+ destinationHash: "",
+ embedded: true,
+ isActive: true,
+ });
+ await wrapper.vm.$nextTick();
+
+ const oldHash = "a".repeat(32);
+ const archiveHash = "b".repeat(32);
+ wrapper.vm.selectedNode = { destination_hash: oldHash, display_name: "A" };
+ wrapper.vm.nodePagePath = `${oldHash}:/page/index.mu`;
+ wrapper.vm.pageArchives = [];
+
+ wrapper.vm.loadArchivedPage(99);
+
+ expect(wrapper.vm.isLoadingNodePage).toBe(true);
+ expect(wrapper.vm.currentPageDownloadId).toEqual(expect.any(Number));
+ const downloadId = wrapper.vm.currentPageDownloadId;
+
+ expect(
+ wrapper.vm.ownsNomadPageDownloadEvent(
+ {
+ destination_hash: archiveHash,
+ page_path: "/page/old.mu",
+ },
+ downloadId
+ )
+ ).toBe(true);
+
+ await wrapper.vm.onWebsocketMessage({
+ data: JSON.stringify({
+ type: "nomadnet.page.download",
+ download_id: downloadId,
+ nomadnet_page_download: {
+ status: "success",
+ destination_hash: archiveHash,
+ page_path: "/page/old.mu",
+ page_content: "<p>from archive</p>",
+ is_archived_version: true,
+ archived_at: "2026-01-01T00:00:00",
+ },
+ }),
+ });
+
+ expect(wrapper.vm.isLoadingNodePage).toBe(false);
+ expect(wrapper.vm.nodePageContent).toBe("<p>from archive</p>");
+ expect(wrapper.vm.currentPageDownloadId).toBeNull();
+ wrapper.unmount();
+ });
+
+ it("archive load failure clears the stuck spinner without a download callback", async () => {
+ const wrapper = mountNomadNetworkPage({
+ destinationHash: "",
+ embedded: true,
+ isActive: true,
+ });
+ await wrapper.vm.$nextTick();
+ wrapper.vm.isLoadingNodePage = true;
+ wrapper.vm.currentPageDownloadId = 4242;
+ wrapper.vm.nodePagePath = `${"c".repeat(32)}:/page/index.mu`;
+
+ await wrapper.vm.onWebsocketMessage({
+ data: JSON.stringify({
+ type: "nomadnet.page.download",
+ download_id: 4242,
+ nomadnet_page_download: {
+ status: "failure",
+ destination_hash: "",
+ page_path: "",
+ failure_reason: "archive not found",
+ },
+ }),
+ });
+
+ expect(wrapper.vm.isLoadingNodePage).toBe(false);
+ expect(wrapper.vm.currentPageDownloadId).toBeNull();
+ expect(wrapper.vm.nodePageContent).toContain("archive not found");
+ expect(ToastUtils.error).toHaveBeenCalled();
+ wrapper.unmount();
+ });
+ });
});
diff --git a/tests/frontend/exploratoryFollowUpOracle.test.js b/tests/frontend/exploratoryFollowUpOracle.test.js
index ba807aef..4ffb669a 100644
--- a/tests/frontend/exploratoryFollowUpOracle.test.js
+++ b/tests/frontend/exploratoryFollowUpOracle.test.js
@@ -37,7 +37,6 @@ describe("follow-up oracles from exploratory hunt", () => {
};
MessagesPage.methods.syncUnreadCount.call(ctx);
-
expect(GlobalState.unreadConversationsCount).toBe(12);
expect(emitSpy).toHaveBeenCalledWith("notifications-changed");
emitSpy.mockRestore();
@@ -63,4 +62,42 @@ describe("follow-up oracles from exploratory hunt", () => {
const loc = await MapPage.methods.resolveMyLocationWgs84.call(ctx);
expect(loc).toEqual({ lon: 11.1, lat: 22.2 });
});
+
+ it("mapViewStateKey scopes TileCache map state by identity", async () => {
+ const { mapViewStateKey, LEGACY_MAP_STATE_KEY } = await import(
+ "../../meshchatx/src/frontend/js/mapStateKeys.js"
+ );
+ const a = "aa".repeat(16);
+ const b = "bb".repeat(16);
+ expect(mapViewStateKey(a)).not.toBe(mapViewStateKey(b));
+ expect(mapViewStateKey(a)).not.toBe(LEGACY_MAP_STATE_KEY);
+ expect(mapViewStateKey(a, "tab-1")).toContain(a.slice(0, 16));
+ expect(mapViewStateKey(a, "tab-1")).not.toBe(mapViewStateKey(b, "tab-1"));
+ });
+
+ it("stale remote overlay generation removes layers added after a newer load started", async () => {
+ const removed = [];
+ const ctx = {
+ map: {},
+ remoteOverlayLoadGeneration: 0,
+ remoteOverlayLayers: {},
+ removeRemoteOverlayLayer(id) {
+ removed.push(id);
+ delete this.remoteOverlayLayers[id];
+ },
+ async ensureRemoteOverlayLayer(overlay) {
+ const id = String(overlay.id);
+ this.remoteOverlayLayers[id] = {
+ layer: { setVisible: vi.fn() },
+ };
+ // Simulate a newer overlay list while fetch is in flight.
+ this.remoteOverlayLoadGeneration = 2;
+ },
+ };
+ await MapPage.methods.onRemoteOverlaysChanged.call(ctx, [
+ { id: "ov1", visible: true, status: "ready", format: "geojson" },
+ ]);
+ expect(removed).toEqual(["ov1"]);
+ expect(ctx.remoteOverlayLayers.ov1).toBeUndefined();
+ });
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────